package org.bhf.providers.router;

import org.bhf.service.Component;
import org.bhf.facilities.Project;
import org.bhf.service.ServiceGroup;
import org.bhf.service.ServiceGroupInfo;
import org.bhf.service.Service;
import org.bhf.service.URLRouter;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * <p> This class is responsible for routing an incoming request to a (module,service,method,[id]) tuple based on
 * route specifications, or patterns. A route specfication start with a '/' on  a new line, followed by the URI
 * path pattern and then zero or more key/value pairs. Each element in the path must be a string or a single key.
 * For example,</p>
 * <code><pre>
 *  /foo/:service/:method/
 *      :module admin
 * </pre></code>
 * <p>This route will map requests starting with /foo/ to a Service of the admin Module. <code>/foo/s2/add/</code> would
 * map to Module 'admin', Service 's2', and method 'add'. Each key (:module, :service, :project,
 * :method, and :id) starts with a ':'. :module, :service, and :method must be specified in either the URI path
 * pattern or in the defaults section, but not both. The :id is optional and used for routes to Component methods.
 * The :project route element is also optional; if not specified, the project is taken to be the subdomain name.</p>
 * Module names, Service names, Method names, and ids are restricted to the following regular expressions.</p>
 * <dl>
 * <dt>:module</dt><dd>[a-zA-Z0-9\-_\.]+)</dd>
 * <dt>:service</dt><dd>[a-zA-Z0-9_\.]+</dd>
 * <dt>:method</dt><dd>[a-zA-Z][a-zA-Z0-9_]*</dd>
 * <dt>:project</dt><dd>[a-zA-Z][a-zA-Z0-9_]*</dd>
 * <dt>:id</dt><dd>[0-9]+</dd>
 * </dl>
 * <p>Routes are specified in the file <code>/META-INF/routes.txt</code>. Blanks lines are allowed and comments start
 * with '#'. An example route specification looks like...
 * <pre><code>
 * # Main Module for secured services
 * /admin/:service/:method/
 *     :module admin
 * /admin/:service/        # Default method
 *     :module admin
 *     :method external
 *
 * #Search
 * /search/get/
 *     :module     token
 *     :service    search
 *     :method     get
 * /search
 *     :module     token
 *     :service    search
 *     :method     external
 *
 * # Module for services that do not require authentication
 * /:service/:method/
 *     :module anonymous
 * /:service/              # Default method
 *     :module anonymous
 *     :method external
 *
 * # Welcome
 * /
 *     :module     anonymous
 *     :service    login
 *     :method     external
 * </code></pre>
 * Since none of the routes include a :project route element, this application uses the subdomain name to specify the
 * project for each request. Another approach would be to scrape the project from the URI; for example.
 * <pre><code>
 * # Member blog
 * /member/:project/:service/:method/
 *     :module blog
 * </code></pre>
 * Here the project is presumably a specific member.
 * </p>
 * <p> When mapping a route to an incoming request, the first route that matches the incoming URI will be used.
 * </p>
 * <p>The BHF needs to know a route to a given Service to, for example, use the service from within a Web page. The BHF,
 * and your code, will use one of the reverse route methods on <code>URLRouter</code> to best the best route to a
 * Service. As more than one route may match, the <code>DefaultURLRouter</code> chooses the best route as the most
 * <em>specific</em> route. A route is specific to a given route element if it as that element specified in the
 * default section rather than part of the pattern match. A route A is more specific than route B if is specifies more
 * route elements in the default section. A route A is more specific than route B, given that they have the same number
 * of default route elements, if A occurs before B in the route table.
 * </p>
 * <p>If you do not provide a <code>/META-INF/routes.txt</code>, <code></code> will use the built-in route table.
 * </p>
 * <pre><code>
 * /:module/:service/:method
 * /member/:project/:service/:method/
 * /:module/:service/:id/:method
 * </code></pre>
 * <p>Note that changing the route table can potentially break existing URLs in links, bookmarks, search engines, etc.
 * Adding new routes will not break existing URLs; however, if a new route is redunant, with respect to a Service,
 * and more specific than an existing route, new URLs to a Service will be different than the previous URLs. Some
 * external entities may regarded the old and new URLs as representing differen Services, which may or my not be
 * a problem for you.
 * </p>
 */
// TODO Reader/Writer thread safety
// TODO Reverse route caching
public class DefaultURLRouter implements URLRouter
{
    /** Default location of the route specifications, utf-8 encoded */
    public static final String  ROUTE_TABLE_LOCATION = "/META-INF/routes.txt";

    /** Default routes if none are provided */
    public static final String[] DEFAULT_ROUTES = {
        "/:module/:service/:method",
        "/:module/:service/:id/:method"
    };

    private final List<Route>   routes  = new ArrayList<Route>();
    private final RouteCache    cache   = new RouteCache();

    /**
     * Read routes from /META-INF/routes, if present. If not present, use the following default routes.
     * <ol>
     *  <li>/:module/:service/:method</li>
     *  <li>/:module/:service/:id/:method</li>
     * <ul>
     * @throws IOException Error reading the route specifications
     */
    public DefaultURLRouter()
        throws IOException
    {
        final InputStream in = getClass().getClassLoader().getResourceAsStream( ROUTE_TABLE_LOCATION );

        if( in != null )
        {
            try
            {
                addRouteSpecifications( in );
            }
            finally
            {
                in.close();
            }
        }
        else
        {
            for( final String route : DEFAULT_ROUTES )
                addRouteSpecification( route );
        }
    }

    /**
     * Attempt to find a route for the given URI. When more than one route matches the URI, the first is chosen.
     *
     * @param extenalURI The incoming URI to be matched.
     * @param hostName If the project is not specified in the URI, it will be taken as the subdomain. <code>hostName</code>
     *  must be a name, not an IP address.
     * @return The route, or <code>null</code> if no route exists.
     */
    public RouteMatch   route( String extenalURI, final String hostName )
    {
        assert extenalURI != null;

        // Condition. We want routes to always start and end with a '/'
        if( !extenalURI.endsWith( "/" ) )
            extenalURI = extenalURI + "/";
        if( !extenalURI.startsWith( "/" ) )
            extenalURI = "/" + extenalURI;

        synchronized( cache )
        {
            {
                final RouteMatch match = cache.route( extenalURI, hostName );
                if( match != null )
                    return match;
            }

            for( final Route route : routes )
            {
                final RouteMatch match = route.match( extenalURI );
                if( match != null )
                {
                    if( match.getProject() == null ) // Take it from the subdomain
                    {
                        final int i = hostName.indexOf( '.' );
                        match.setProject( i == -1 ? hostName : hostName.substring( 0, i ) );
                    }
                    cache.addRoute( extenalURI, hostName, match );
                    return match;
                }
            }
        }

        return null;
    }

    /**
     * Return the most specific route to this Service or Component method. If more than one route can be used to
     * each this method, the most specific will be chosen. Route A is more specific than route B, if A has more
     * of the route 'hardwired' with key/value pairs than route B. If routes are equalls specific, the first is chosen.
     *
     * @param service Service class, Msut not be <code>null</code>
     * @param method Simple method name. Must not be <code>null</code>
     * @param project If <code>null</code> the ID of the <code>ContextProject</code> will be used.
     * @return The URI that can be used to reach this Service or Component method.
     * @throws RuntimeException If no route can be found.
     */
    public String       reverseRoute( final Class<?> service, final String method, String project )
    {
        return reverseRoute( service, method, project, -1 );
    }

    /**
     * Return the most specific route to this Component method. If more than one route can be used to
     * each this method, the most specific will be chosen. Route A is more specific than route B, if A has more
     * of the route 'hardwired' with key/value pairs than route B. If routes are equalls specific, the first is chosen.
     *
     * @param component Component object, Msut not be <code>null</code>
     * @param method Simple method name. Must not be <code>null</code>
     * @param project If <code>null</code> the ID of the <code>ContextProject</code> will be used.
     * @return The URI that can be used to reach this Component method.
     * @throws RuntimeException If no route can be found.
     */
    public String       reverseRoute( final Component component, final String method, String project )
    {
        assert component != null;

        return reverseRoute( component.getClass(), method, project, component.getId() );
    }

    /**
     * Return the most specific route to this Component method. If more than one route can be used to
     * each this method, the most specific will be chosen. Route A is more specific than route B, if A has more
     * of the route 'hardwired' with key/value pairs than route B. If routes are equalls specific, the first is chosen.
     *
     * @param service Component object, Must not be <code>null</code>
     * @param method Simple method name. Must not be <code>null</code>
     * @param projectName If <code>null</code> the ID of the <code>ContextProject</code> will be used.
     * @param id Unique id if this Service is a Component, or -1 if this Service is simply a Service.
     * @return The URI that can be used to reach this Component method.
     * @throws RuntimeException If no route can be found.
     */
    public String       reverseRoute( final Class<?> service, final String method, String projectName, final long id )
    {
        assert service != null;
        assert method != null;

        final ServiceGroup module         = ServiceGroup.getServiceGroupForService( service );

        if( module == null )
            throw new RuntimeException( "Service " + service.getName() + " is not registered with a Module" );

        final String moduleName     = ServiceGroupInfo.Util.getName( module.getClass() );
        final String serviceName    = Service.Util.getName( service );
              int    bestScore      = -1;
              Route  bestRoute      = null;

        if( projectName == null )
        {
            final Project p = Project.getContextProject();
            projectName = p != null ? p.getName() : null;
            if( projectName == null )
                throw new RuntimeException( "ContextProject not set" );
        }

        for( final Route route : routes )
        {
            int score = 0;

            if( route.module != null && !route.module.equals( moduleName ) )
                continue;
            if( route.service != null && !route.service.equals( serviceName ) )
                continue;
            if( route.method != null && !route.method.equals( method ) )
                continue;
            if( route.project != null && !route.project.equals( projectName ) )
                continue;
            if( id != -1 )
            {
                if( route.id != -1 && route.id != id )
                    continue;
                if( route.id == -1 && route.idGroup == -1 ) // No id, so not a service route
                    continue;
            }

            if( route.module != null )
                score++;
            if( route.service != null )
                score++;
            if( route.method != null )
                score++;
            if( id != -1 )
            {
                if( route.id != -1 )
                    score++;
            }

            if( score > bestScore )
            {
                bestRoute = route;
                bestScore = score;
            }
        }

        if( bestRoute == null )
        {
            Package.log.log( Level.SEVERE, "No route available to module " + moduleName + ", service " + serviceName + ", method " + method );
            throw new RuntimeException( "No route to service: " + service.getName() + "." + method );
        }

        String URI = subelement( "/" + bestRoute.specification + "/", ":module", moduleName );
        URI = subelement( URI, ":service", serviceName );
        URI = subelement( URI, ":method", method );
        URI = subelement( URI, ":project", projectName );
        if( id != -1 )
        {
            URI = subelement( URI, ":id", Long.toString( id ) );
        }

        if( URI.endsWith( "*/") )
            URI = URI.substring( 0, URI.length() - 2 );

        return URI;
    }

    //
    // Package (for testing)
    //

    /**
     * Add a route specification to this router.
     * @param specification Must not be <code>null</code>
     */
    void                addRouteSpecification( final String specification )
    {
        assert specification != null;

        routes.add( new Route( specification ) );
    }

    /**
     * Remove all route specifications from this router.
     */
    void                clearRouteSpecifications()
    {
        routes.clear();
    }

    //
    // Private
    //

    /**
     * Add route specifications to this router. Each specification starts with a '/' as the first character in a
     * line.
     * @param specificationSource Specification source, UTF-8 encoded.
     * @throws IOException Error reading from the input
     */
    private void        addRouteSpecifications( final InputStream specificationSource )
        throws IOException
    {
        assert specificationSource != null;

        final BufferedReader data = new BufferedReader( new InputStreamReader( specificationSource, Charset.forName( "utf-8" ) ) );

        try
        {
            final StringBuffer specification = new StringBuffer();
            while( true )
            {
                String line = data.readLine();
                if( (line == null || (line.length() > 0 && (line.trim().charAt( 0 ) == '/' || line.trim().charAt( 0 ) == '!'))) && specification.length() > 0 )
                {
                    specification.setLength( specification.length() - 1 ); // Remove trailing ' '
                    addRouteSpecification( specification.toString() );
                    specification.setLength( 0 );
                }
                if( line == null )
                {
                    break;
                }

                // Remove comments and trim
                {
                    final int i = line.indexOf( '#' );
                    if( i >= 0 )
                    {
                        line = line.substring( 0, i );
                    }
                    line = line.trim();
                }

                if( line.length() > 0 )
                {
                    specification.append( line );
                    specification.append( ' ' ); // Will remove extra ' ' later
                }
            }

        }
        finally
        {
            data.close();
        }
    }

    private String      subelement( final String specification, final String key, final String value )
    {
        final int i = specification.indexOf( key );
        if( i == -1 )
            return specification;

        return specification.substring( 0, i ) + value + specification.substring( i + key.length() );
    }


    //
    // Parser
    //

    final static class Route
    {
        static final String METHOD_PATTERN          = "([a-zA-Z][a-zA-Z0-9_]*)";
        static final String MODULE_PATTERN          = "([a-zA-Z0-9\\-_\\.]+)";
        static final String PROPERTY_PATH_PATTERN   = "([a-zA-Z0-9_\\.]+)";
        static final String NUMBER_PATTERN          = "([0-9]+)";
        static final String WILD_RUN                = "(.*)";

        final String    specification;
        final Pattern   pattern;
              String    module          = null;
              String    service         = null;
              String    method          = null;
              String    project         = null;
              boolean   antiRoute       = false;
              long      id              = -1;
              int       moduleGroup     = -1;
              int       serviceGroup    = -1;
              int       methodGroup     = -1;
              int       idGroup         = -1;
              int       projectGroup    = -1;
              int       wildGroup       = -1;

        Route( final String specification )
        {
            final String[] tokens   = specification.split( "\\s+" );
            if( tokens.length == 0 || isEven( tokens.length ) )
                throw new RuntimeException( "Route syntax error in '" + specification + "'" );

            String path = tokens[0];

            // Anti-route
            if( path.length() > 0 && path.charAt( 0 ) == '!' )
            {
                antiRoute = true;
                path = path.substring( 1 );
            }

            // Incoming URIs always have a trailing / - its added if not there - and a leading /. Thus, the pattern
            // should always begin and end with a / as well. However, '/' is considered to be both beginning and
            // ending with a /. Such a pattern has to be handled specially. Strip leading and trailing from the pattern,
            // add leading and trailing, but handle '/' as a special.
            while( path.length() > 0 && path.charAt( 0 ) == '/' )
                path = path.substring( 1 );
            while( path.length() > 0 && path.charAt( path.length() - 1 ) == '/' )
                path = path.substring( 0, path.length() - 1 );

            if( path.length() == 0 )
            {
                this.specification  = path;
                this.pattern        = Pattern.compile( "/" );
            }
            else
            {
                final StringBuffer  pattern = new StringBuffer( "/" );
                      int           group   = 1;

                for( final String element : path.split( "/" ) )
                {
                    if( ":module".equals( element ) )
                    {
                        moduleGroup = group;
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( MODULE_PATTERN );
                        group++;
                    }
                    else if( ":service".equals( element ) )
                    {
                        serviceGroup = group;
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( PROPERTY_PATH_PATTERN );
                        group++;
                    }
                    else if( ":method".equals( element ) )
                    {
                        methodGroup = group;
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( METHOD_PATTERN );
                        group++;
                    }
                    else if( ":id".equals( element ) )
                    {
                        idGroup = group;
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( NUMBER_PATTERN );
                        group++;
                    }
                    else if( ":project".equals( element ) )
                    {
                        projectGroup = group;
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( METHOD_PATTERN );
                        group++;
                    }
                    else if( "*".equals( element ) )
                    {
                        wildGroup = group;
                        pattern.append( WILD_RUN );
                        group++;
                    }
                    else
                    {
                        if( wildGroup != -1 ) wildGroup = -1;
                        pattern.append( Pattern.quote( element ) );
                    }
                    pattern.append( '/' );
                }

                this.specification  = path;
                this.pattern        = Pattern.compile( pattern.toString() );
            }

            // Default
            {
                for( int i = 1; i < tokens.length - 1; i += 2 )
                {
                    final String identifier = tokens[i];
                    final String value      = tokens[i + 1];


                    if( ":module".equals( identifier ) )
                    {
                        module = value;
                    }
                    else if( ":service".equals( identifier ) )
                    {
                        service = value;
                    }
                    else if( ":method".equals( identifier ) )
                    {
                        method = value;
                    }
                    else if( ":id".equals( identifier ) )
                    {
                        id = Long.parseLong( value );
                    }
                    else if( ":project".equals( identifier ) )
                    {
                        project = value;
                    }
                    else
                    {
                        throw new RuntimeException( "Route syntax error in '" + specification + "'" + "( " + identifier + "is not :module,:service,:method,or :id)" );
                    }
                }
            }

            if( !antiRoute )
            {
                // Check that we are covered
                if( moduleGroup == -1 && module == null )
                    throw new RuntimeException( ":module not specified in route " + specification );
                if( serviceGroup == -1 && service == null )
                    throw new RuntimeException( ":service not specified in route " + specification );
                if( methodGroup == -1 && method == null )
                    throw new RuntimeException( ":method not specified in route " + specification );

                // And not duplicated
                if( moduleGroup != -1 && module != null )
                    throw new RuntimeException( ":module specification redundant in " + specification );
                if( serviceGroup != -1 && service != null )
                    throw new RuntimeException( ":service specification redundant in " + specification );
                if( methodGroup != -1 && method != null )
                    throw new RuntimeException( ":method specification redundant in " + specification );
                if( idGroup != -1 && id != -1 )
                    throw new RuntimeException( ":id specification redundant in " + specification );
            }
        }

        boolean isEven( final int x )
        {
            return (x & 0x0001) == 0;
        }

        RouteMatch match( final String extenalURI )
        {
            final Matcher matcher = pattern.matcher( extenalURI );
            if( matcher.matches() )
            {
                final RouteMatch routeMatch = new RouteMatch( module, service, method, id, extenalURI, project );
                if( moduleGroup != -1 )
                    routeMatch.setServiceGroup( matcher.group( moduleGroup ) );
                if( serviceGroup != -1 )
                    routeMatch.setService( matcher.group( serviceGroup ) );
                if( methodGroup != -1 )
                    routeMatch.setMethod( matcher.group( methodGroup ) );
                if( idGroup != -1 )
                    routeMatch.setId( Long.parseLong( matcher.group( idGroup ) ) );
                if( projectGroup != -1 )
                    routeMatch.setProject( matcher.group( projectGroup ) );
                if( wildGroup != -1 )
                {
                    final String trailer = matcher.group( wildGroup );
                    // We always have a trailing '/', so we need +1
                    routeMatch.setContext( extenalURI.substring( 0, extenalURI.length() - (trailer.length() + 1) ) );
                }
                else
                {
                    routeMatch.setContext( extenalURI );
                }

                routeMatch.setAntiRoute( antiRoute );

                return routeMatch;
            }
            else
            {
                return null;
            }
        }
    }
}
